Skip to content

feat(identity): offline Ed25519 verifier — issuer-free identity for the auth seam - #234

Merged
rhCat merged 1 commit into
mainfrom
feat/ed25519-verifier
Jul 26, 2026
Merged

feat(identity): offline Ed25519 verifier — issuer-free identity for the auth seam#234
rhCat merged 1 commit into
mainfrom
feat/ed25519-verifier

Conversation

@rhCat

@rhCat rhCat commented Jul 26, 2026

Copy link
Copy Markdown
Owner

First verifier for the seam merged in #232, and deliberately not an IdP one.

Why not OIDC first

An OIDC verifier checks a token offline, but obtaining one needs the internet and the IdP up — and shortening token lifetimes to tighten revocation makes that dependency worse, so the two cannot be tuned together:

long tokens   -> better offline tolerance, worse revocation lag
short tokens  -> tighter revocation, more dependent on the IdP

This fleet is tailnet-scoped and dependency-free on purpose. An external IdP would be the first service the governance kernel needs up to function, and a third party could lock the operator out of approving actions on their own fleet. This has no issuer, no JWKS, no network in either direction.

The shape

The bearer is a self-contained short-lived assertion: a DSSE envelope — the same infra.cwp.sign surface that already signs grants and exod results, no new crypto — over {pub, iat, exp, nonce}, base64url'd to fit an Authorization header. It carries its own public key, the signature is checked against that key, and the verifier returns sign.keyid(pub) = ed25519:<16 hex> as the subject.

That is not circular, and the distinction is the whole design. Anyone can mint a valid assertion with a key they generated, and it will verify. What they cannot do is make it resolve to a principal — resolve_principal matches a subject only against one a registry entry declares:

"alice": { "subject": "ed25519:9f2c…", "acl": {  } }

So the signature proves possession of a key; the mounted principals.json decides whether that key is anybody. Revocation is deleting a line from a file already bind-mounted read-only into every node — instant, offline, no expiry window, no issuer.

Fail-closed — 21 tests, each asserting a refusal

  • a valid assertion from an undeclared key resolves to nobody (the property that matters most)
  • expired; future-dated (a lying clock cannot post-date its way to a long credential); a hand-rolled assertion claiming a year-long TTL — the verifier does not trust the minter's clamp
  • tampered payload — swapping the embedded pubkey breaks the signature; it is self-verifying
  • pubkey not 32 bytes; malformed bearers; a bearer secret under this scheme
  • replay — a spent nonce is refused inside its validity window, and when the bounded cache is full of live nonces it refuses rather than evicting one and permitting a replay

Mutation-verified: disabling the replay check fails 2 tests; disabling signature verification fails the tamper test.

The verifier never raises and never reveals why it refused — a caller that could tell "bad signature" from "expired" from "replayed" is an oracle, and govd answers 401 either way.

Verification

Governed gate (cws-pm, 16 validators incl. grant forge / replay / expiry / wrong-run / capability): 16/16 (5d8cb29162d34fa4). Local suites green: ed25519_auth, principals, govd, fleetd, acl.

Next, not this PR

The OIDC overlay sits above this, never beside it: authenticate to an IdP, receive a short-lived Ed25519 certificate signed by an operator CA, verified offline against a pinned CA pubkey — the SSH-CA / Sigstore pattern. That moves the online dependency from every claim to periodic enrollment, and degrades correctly: without the overlay, this half is a complete working system, enrolled by hand via gen-acl.sh.

🤖 Generated with Claude Code

…he auth seam

The first verifier for the seam merged in #232, and deliberately not an IdP one.

WHY NOT OIDC FIRST. An OIDC verifier checks a token offline, but *obtaining* one needs the internet and the
IdP up — and shortening token lifetimes to tighten revocation makes that dependency worse, so the two cannot
be tuned together. This fleet is tailnet-scoped and dependency-free on purpose (tailnet-only binds,
deny-by-default ACLs, --unshare-net on confined steps, "tailnet = reachability, not authorization"); an
external IdP would be the first service the governance kernel needs UP to function, and a third party could
lock the operator out of approving actions on their own fleet. This has no issuer, no JWKS, and no network in
either direction.

THE SHAPE. The bearer is a self-contained short-lived ASSERTION: a DSSE envelope — the same infra.cwp.sign
surface that already signs grants and exod results, no new crypto — over
{pub, iat, exp, nonce}, base64url'd to fit an Authorization header. The assertion carries its own public key,
the signature is checked against THAT key, and the verifier returns sign.keyid(pub) = "ed25519:<16 hex>" as
the subject.

That is not circular, and the distinction is the whole design: anyone can mint a valid assertion with a key
they generated and it WILL verify. What they cannot do is make it resolve to a principal — resolve_principal
matches a subject only against one a registry entry DECLARES. So the signature proves possession of a key;
the mounted principals.json decides whether that key is anybody. Revocation is deleting a line from a file
already bind-mounted read-only into every node: instant, offline, no expiry window to wait out, no issuer to
consult.

Fail-closed everywhere, 21 tests, each asserting a refusal:
- a VALID assertion from an undeclared key resolves to nobody (the property that matters most);
- expired, future-dated (a lying clock cannot post-date its way to a long credential), and a hand-rolled
  assertion claiming a year-long TTL — the verifier does not trust the minter's clamp;
- tampered payload (swapping the embedded pubkey breaks the signature — it is self-verifying);
- a pubkey that is not 32 bytes; malformed/undecodable bearers; a bearer SECRET under this scheme;
- REPLAY: a spent nonce is refused inside its own validity window, and when the bounded cache is full of
  LIVE nonces it refuses rather than evicting one and permitting a replay.

Mutation-verified: disabling the replay check fails 2 tests; disabling signature verification fails the
tamper test. The verifier never raises and never reveals WHY it refused — a caller that could tell "bad
signature" from "expired" from "replayed" is an oracle, and govd answers 401 either way.

Governed gate (cws-pm, 16 validators incl. grant forge/replay/expiry/wrong-run/capability): 16/16
(5d8cb29162d34fa4). Local: ed25519_auth, principals, govd, fleetd, acl all green.

NEXT (not this PR): the OIDC overlay sits ABOVE this, never beside it — authenticate to an IdP, receive a
short-lived Ed25519 certificate signed by an operator CA, verified offline against a pinned CA pubkey. The
SSH-CA / Sigstore pattern. That moves the online dependency from every claim to periodic enrollment, and
degrades correctly: without the overlay this half is a complete working system, enrolled by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rhCat
rhCat merged commit 790b008 into main Jul 26, 2026
2 checks passed
@rhCat
rhCat deleted the feat/ed25519-verifier branch July 26, 2026 02:58
rhCat added a commit that referenced this pull request Jul 26, 2026
…isters nothing (#235)

* fix(identity): actually WIRE the built-in verifier — a config key registers nothing

#234 shipped the Ed25519 verifier and #232 the seam, and between them the feature was unusable: nothing
ever called register_verifier. Setting auth_verifier="ed25519" made every claim fail closed — correct
behaviour, and a silently dead feature.

Found by standing a real govd up from merged main with auth_verifier="ed25519" and watching a valid
assertion resolve to nobody. No unit test could have caught it: both halves were individually correct.

serve() now calls install_builtin_verifier(cfg) before the first claim. Only a name we SHIP is installed; an
unknown name installs nothing and therefore resolves nobody, which is the intended fail-closed behaviour for
a typo — it must never fall back to the bearer-secret path. Verifier CODE stays in the image by design: a
verifier loaded from the mounted config would be ungoverned code executing inside the syscall boundary,
which is the one thing this system exists to prevent. The mount configures WHICH verifier, never its body.

Verified against a real govd over real HTTP (local, merged main, ed25519 scheme):

  alice's assertion   -> 200 allow, and the signed chain records principal=alice
  undeclared key      -> 401
  bearer secret       -> 401
  no credential       -> 401

That `principal=alice` is the point of the whole exercise: a named identity in the audit trail, which a
shared per-node token can never provide.

TESTS. Two unit tests pin the helper, and — because mutation showed deleting the serve() call left every
unit test GREEN — an integration test starts a real govd on a free port and authenticates over HTTP, so the
CALL SITE is covered, not just the function. Re-mutated after adding it: the suite now goes red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(govd): cover install_builtin_verifier in govd.py's RATCHET SLICE

CI's enforcement-surface mutation ratchet caught this and it was right to:

  [FAIL] infra/govern/govd.py: score=0.95 floor=1.0  survivors=['==->!=@9986']
  MUTATION REGRESSION below floor: [('infra/govern/govd.py', 0.95, 1.0)]

govd.py was at a perfect 1.0 — every mutant killed — and the new `if name == "ed25519"` branch dropped it
to 0.95.

The branch was NOT untested: tests/test_ed25519_auth.py kills that mutant. But the ratchet drives each
enforcement-surface module against a DESIGNATED slice (infra/govern/selfmonitor_policy.json:
govd.py -> tests/test_govd.py), so a proof living anywhere else is invisible to it. Coverage for code in
govd.py belongs in govd.py's slice; that policy is the point, not an obstacle to route around — the
alternative (widening the slice) would dilute the ratchet for every module.

Verified by re-running the exact CI survivor locally: mutating `==` to `!=` now fails
tests/test_govd.py::test_install_builtin_verifier_registers_only_a_name_we_ship, and the suite is green
restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant